admin / Synapse-Sonar
publicAttack Surface Simulation
Synapse-Sonar / synapse-sonar / app / api / invite / [token] / route.ts
2865 B · main
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 | import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import bcrypt from "bcryptjs"; import { prisma } from "@/lib/prisma"; export const runtime = "nodejs"; /** * Invite acceptance — public (token-authenticated) flow. * * GET /api/invite/:token → validate token, return display info for the page. * POST /api/invite/:token → set password + activate the account. * * The single-use invite token (created by POST /api/users) is the only * credential here; it is consumed (nulled) on successful acceptance. */ const ROLE_LABEL: Record<string, string> = { TENANT_ADMIN: "Tenant Admin", SECURITY_ANALYST: "Security Analyst", READ_ONLY_VIEWER: "Read-Only Viewer", }; export async function GET( _req: NextRequest, { params }: { params: { token: string } } ) { const user = await prisma.user.findUnique({ where: { inviteToken: params.token }, include: { tenant: { select: { name: true, slug: true } } }, }); if (!user || user.status !== "INVITED") { return NextResponse.json( { error: "This invitation is invalid or has already been used." }, { status: 404 } ); } return NextResponse.json({ username: user.username, name: user.name ?? "", roleLabel: ROLE_LABEL[user.role] ?? user.role, tenantName: user.tenant.name, }); } const AcceptSchema = z.object({ name: z.string().min(1).max(80).optional(), password: z.string().min(10, "Use at least 10 characters"), }); export async function POST( req: NextRequest, { params }: { params: { token: string } } ) { let body; try { body = AcceptSchema.parse(await req.json()); } catch (err) { if (err instanceof z.ZodError) { return NextResponse.json( { error: "Invalid input", details: err.issues }, { status: 422 } ); } return NextResponse.json({ error: "Invalid request" }, { status: 400 }); } const user = await prisma.user.findUnique({ where: { inviteToken: params.token }, }); if (!user || user.status !== "INVITED") { return NextResponse.json( { error: "This invitation is invalid or has already been used." }, { status: 404 } ); } // Consume the token, set credentials, activate. updateMany with the token // guard makes this idempotent-safe against a double submit. const result = await prisma.user.updateMany({ where: { id: user.id, inviteToken: params.token, status: "INVITED" }, data: { passwordHash: await bcrypt.hash(body.password, 12), status: "ACTIVE", inviteToken: null, ...(body.name ? { name: body.name } : {}), }, }); if (result.count === 0) { return NextResponse.json( { error: "This invitation is invalid or has already been used." }, { status: 409 } ); } return NextResponse.json({ ok: true, username: user.username }); } |